Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit adcb29809a83da62959f3217b65584270b1e0e48


Parents : 2a10424
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-08T16:01:52-05:00

feat(Bot Lifecycle): implement comprehensive bot lifecycle management with self-test integration, including creation, starting, stopping, and deletion of Echo bots. Enhance diagnostics to report bot status and errors, and update UI for self-test results. Add support for frozen executable handling in bot processes.

Changes
Diff

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 60e678fc..52521c82 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,29 +7,30 @@ All notable changes to this project will be documented in this file.
### Added
- **Plugins**: JS/WASM plugin system with capability-based permissions, Worker-sandboxed frontend runtime, wasmtime-backed backend runtime, generic `/api/v1/plugins/*` API, and plugin management under **Settings → Maintenance** (drag-and-drop ZIP install, enable/disable, remove with confirmation, UI/WASM badges, empty state).
-- **Plugins**: Contribution-point registries for sidebar navigation, tools catalog, command palette, settings sections, and typed WebSocket event dispatch — core UI surfaces are data-driven instead of hardcoded per component.
-- **Plugins**: **Bundled i18n** — plugins ship `locales/{locale}.json` in the package; the host loads labels from plugin assets so third-party plugins do not need changes to MeshChatX main locale files.
-- **Plugins**: Declarative UI slot vocabulary in `PluginSlotRenderer` / `PluginSlotNode` — sections, action button rows, badges, card lists, grid rows, and text variants for plugin tool pages.
-- **Plugins**: Bundled **Mesh Observatory** example plugin — live announce feed, searchable path table with hop/interface/state columns, and announce-driven refresh.
-- **Plugins**: Security guardrails in `plugin_guard.py` — ZIP size/magic validation, zip-slip protection, asset path normalization, WASM size/magic checks, invoke payload limits, and an error budget that auto-disables misbehaving plugins after repeated failures.
-- **Plugins**: `POST /api/v1/plugins/{id}/report-failure` for frontend worker crash reporting; kill-switch broadcasts over WebSocket when a plugin is auto-disabled.
+- **Plugins**: Contribution-point registries for sidebar navigation, tools catalog, command palette, settings sections, and typed WebSocket event dispatch. Core UI surfaces are data-driven instead of hardcoded per component.
+- **Plugins**: **Bundled i18n**. Plugins ship `locales/{locale}.json` in the package. The host loads labels from plugin assets so third-party plugins do not need changes to MeshChatX main locale files.
+- **Plugins**: Declarative UI slot vocabulary in `PluginSlotRenderer` / `PluginSlotNode` for sections, action button rows, badges, card lists, grid rows, and text variants on plugin tool pages.
+- **Plugins**: Bundled **Mesh Observatory** example plugin with live announce feed, searchable path table with hop/interface/state columns, and announce-driven refresh.
+- **Plugins**: Security guardrails in `plugin_guard.py` for ZIP size/magic validation, zip-slip protection, asset path normalization, WASM size/magic checks, invoke payload limits, and an error budget that auto-disables misbehaving plugins after repeated failures.
+- **Plugins**: `POST /api/v1/plugins/{id}/report-failure` for frontend worker crash reporting. Kill-switch broadcasts over WebSocket when a plugin is auto-disabled.
- **Dependencies**: Added **wasmtime** for backend WASM plugin execution.
- **Plugins**: `--disable-plugins` CLI flag and `MESHCHAT_DISABLE_PLUGINS` environment variable to disable the plugin system entirely at runtime.
-- **Vendored LXMFy**: Refreshed `vendor/lxmfy` to upstream **1.6.5** (`d92cfe0`) — Landlock LSM sandbox for bot processes and external cogs, propagation-node init fix, cog permission fix, and dependency alignment with RNS 1.3.5+ / LXMF 1.0.1+.
-- **Mutation testing**: Backend uses **mutmut** (`task test:mutation:backend`); frontend uses in-repo **MeshMut** (`task test:mutation:frontend`) with regex-based mutators and Vitest for pure JS modules. Optional `mutation.yml` workflow for manual or scheduled runs.
+- **Vendored LXMFy**: Refreshed `vendor/lxmfy` to upstream **1.6.5** (`d92cfe0`) with Landlock LSM sandbox for bot processes and external cogs, propagation-node init fix, cog permission fix, and dependency alignment with RNS 1.3.5+ / LXMF 1.0.1+.
+- **Mutation testing**: Backend uses **mutmut** (`task test:mutation:backend`). Frontend uses in-repo **MeshMut** (`task test:mutation:frontend`) with regex-based mutators and Vitest for pure JS modules. Optional `mutation.yml` workflow for manual or scheduled runs.
### Fixed
+- **Bots / macOS**: Creating or starting LXMFy bots from a frozen desktop build no longer re-launches a second MeshChatX instance (and hit the storage lock). Bot subprocesses re-enter ``bot_process`` via ``--meshchatx-run-module``.
+- **Self-Test**: Diagnostics now include a live **bot create / start / stop / delete** check (also covered by ``--self-check`` CI and E2E smoke).
- **RNSh / Windows**: Frozen desktop builds no longer launch rnsh via ``python -m`` (``sys.executable`` is MeshChatX itself and rejects ``-m``). Sessions re-enter the bundled rnsh module with ``--meshchatx-run-module``.
- **CI / nightly**: Daily ``nightly-YYYY.MM.DD-<sha>`` tags from ``dev`` now explicitly ``workflow_dispatch`` ``build-release.yml`` after tagging so full release assets are produced.
-
- **Plugins**: Plugin worker `postRequest` Promise wrapper, plugin locale loading at boot, cached UI on page open, and slot renderer recursion for nested column/list/row children.
-- **Plugins**: Mesh Observatory layout — spaced action buttons, section cards, truncated interface names, and state badges instead of squashed single-line rows.
-- **RNode / Android**: Hardened `rnode_support` startup guards — desktop TCP RNode no longer incorrectly requires pyserial; desktop BLE now checks for bleak instead of pyserial; whitespace-only Bluetooth ports classify correctly; invalid `tcp:///` hosts are no longer backfilled; `RNodeIPInterface` entries get `tcp_host` backfill on Android; RNodeMulti sibling sub-interfaces with invalid TX power are detected and disabled; txpower guard honors both `enabled` and `interface_enabled` keys.
+- **Plugins**: Mesh Observatory layout with spaced action buttons, section cards, truncated interface names, and state badges instead of squashed single-line rows.
+- **RNode / Android**: Hardened `rnode_support` startup guards. Desktop TCP RNode no longer incorrectly requires pyserial. Desktop BLE now checks for bleak instead of pyserial. Whitespace-only Bluetooth ports classify correctly. Invalid `tcp:///` hosts are no longer backfilled. `RNodeIPInterface` entries get `tcp_host` backfill on Android. RNodeMulti sibling sub-interfaces with invalid TX power are detected and disabled. Txpower guard honors both `enabled` and `interface_enabled` keys.
- **RNode / desktop**: Added **bleak** as a core dependency and a desktop startup guard that disables unsupported RNode interfaces before Reticulum starts, fixing backend crashes when RNode over BLE is configured on Windows without bleak installed ([#46](https://github.com/Quad4-Software/MeshChatX/issues/46)).
- **CI / tests**: Dependency contract test for bleak, startup integration test for the desktop RNode guard, and cx_Freeze build verification that bleak is bundled.
- **Reticulum config**: Startup repair helpers validate required sections and parseability before applying default config (`reticulum_config_guard.py`).
-- **WebSocket / security**: Config mutators over WebSocket are restricted — sensitive settings (e.g. `auth_enabled`, `auth_password_hash`) must use CSRF-protected HTTP endpoints (`websocket_config_guard.py`).
+- **WebSocket / security**: Config mutators over WebSocket are restricted. Sensitive settings (e.g. `auth_enabled`, `auth_password_hash`) must use CSRF-protected HTTP endpoints (`websocket_config_guard.py`).
- **Settings**: Tabbed settings navigation with section-to-tab mapping, search across tabs, and `SettingsNav` component.
- **Settings**: Plugin settings search no longer treats `index.mu` / `index.html` literals as missing i18n keys.
@@ -37,12 +38,12 @@ All notable changes to this project will be documented in this file.
- **Settings**: Settings section search keywords moved into `settingsSectionRegistry` for reuse by plugins and core sections.
- **App shell**: WebSocket handling in `App.vue` migrated to typed per-event handlers via `wsEventRegistry`.
-- **Locales**: Main app locale files retain only **Settings → Plugins** UI strings; per-plugin copy lives in each plugin bundle.
+- **Locales**: Main app locale files retain only **Settings → Plugins** UI strings. Per-plugin copy lives in each plugin bundle.
### Tests
-- **Plugins**: Registry, manifest, plugin labels, WebSocket event router, `PluginManager`, and `plugin_guard` unit tests (including zip-slip rejection and fuzzed invalid install payloads); HTTP API contract updated for plugin routes.
-- **Tests**: Expanded `rnode_support` coverage (67 tests, including hypothesis fuzzing and full startup repair sequence); settings tabs contract tests, i18n key validation, and `SettingsNav` component tests.
+- **Plugins**: Registry, manifest, plugin labels, WebSocket event router, `PluginManager`, and `plugin_guard` unit tests (including zip-slip rejection and fuzzed invalid install payloads). HTTP API contract updated for plugin routes.
+- **Tests**: Expanded `rnode_support` coverage (67 tests, including hypothesis fuzzing and full startup repair sequence). Settings tabs contract tests, i18n key validation, and `SettingsNav` component tests.
- **Security**: `test_websocket_config_security.py` and `test_reticulum_config_guard.py` for WebSocket config denylist and Reticulum config repair behavior.
## [4.7.2] - 2026-07-06

diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index e9fabe05..beb35e97 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -754,6 +754,104 @@ class ReticulumMeshChat:
if self.current_context:
self.current_context.storage_path = value
+ def _check_bot_lifecycle(self) -> tuple[bool, str]:
+ """Create, start, stop, and delete an Echo bot subprocess.
+
+ Uses an isolated identity + Reticulum config under storage so the check
+ does not touch user bots or ``~/.reticulum``.
+ """
+ from meshchatx.src.backend.bot_handler import BotHandler
+
+ if not self.storage_path or not os.path.exists(self.storage_path):
+ return False, "Storage directory does not exist"
+
+ root = os.path.join(self.storage_path, ".self_test_bots")
+ identity_dir = os.path.join(root, "identity")
+ rns_dir = os.path.join(root, "reticulum")
+ bot_id = None
+ handler = None
+ try:
+ if os.path.isdir(root):
+ shutil.rmtree(root, ignore_errors=True)
+ os.makedirs(identity_dir, exist_ok=True)
+ os.makedirs(rns_dir, exist_ok=True)
+
+ previous_rns = os.environ.get("MESHCHAT_BOT_RETICULUM_CONFIG_DIR")
+ os.environ["MESHCHAT_BOT_RETICULUM_CONFIG_DIR"] = rns_dir
+ try:
+ handler = BotHandler(identity_path=identity_dir)
+ bot_id = handler.start_bot("echo", "Self-Check Echo Bot")
+ finally:
+ if previous_rns is None:
+ os.environ.pop("MESHCHAT_BOT_RETICULUM_CONFIG_DIR", None)
+ else:
+ os.environ["MESHCHAT_BOT_RETICULUM_CONFIG_DIR"] = previous_rns
+
+ entry = next(
+ (e for e in handler.bots_state if e.get("id") == bot_id),
+ None,
+ )
+ if entry is None:
+ return False, "Bot entry missing after start"
+
+ pid = entry.get("pid")
+ if not pid:
+ return False, "Bot process did not report a pid"
+
+ deadline = time.monotonic() + 12.0
+ while time.monotonic() < deadline:
+ if not BotHandler._is_pid_alive(pid):
+ last_err = BotHandler._read_bot_last_error(entry.get("storage_dir"))
+ log_tail = ""
+ try:
+ log_info = handler.read_subprocess_log(bot_id, max_bytes=2048)
+ log_tail = (log_info.get("log") or "").strip()[-500:]
+ except Exception:
+ pass
+ detail = last_err or log_tail or "process exited early"
+ return False, f"Bot process died after start: {detail}"
+ # Confirm the launcher wrote something (script or run-module path).
+ try:
+ log_info = handler.read_subprocess_log(bot_id, max_bytes=1024)
+ if log_info.get("total_bytes", 0) > 0:
+ break
+ except Exception:
+ pass
+ time.sleep(0.2)
+ else:
+ if not BotHandler._is_pid_alive(pid):
+ return False, "Bot process exited before becoming ready"
+ # Still alive with empty log is acceptable (buffered / slow start).
+
+ if not handler.stop_bot(bot_id):
+ return False, "stop_bot returned False"
+ stop_deadline = time.monotonic() + 5.0
+ while time.monotonic() < stop_deadline and BotHandler._is_pid_alive(pid):
+ time.sleep(0.1)
+ if BotHandler._is_pid_alive(pid):
+ return False, f"Bot pid {pid} still alive after stop"
+
+ storage_dir = entry.get("storage_dir")
+ if not handler.delete_bot(bot_id):
+ return False, "delete_bot returned False"
+ if any(e.get("id") == bot_id for e in handler.bots_state):
+ return False, "Bot still present in state after delete"
+ if storage_dir and os.path.exists(storage_dir):
+ return False, "Bot storage directory still exists after delete"
+
+ return True, ""
+ except Exception as e:
+ return False, f"Bot lifecycle check failed: {e}"
+ finally:
+ if handler is not None and bot_id is not None:
+ with contextlib.suppress(Exception):
+ handler.stop_bot(bot_id)
+ with contextlib.suppress(Exception):
+ handler.delete_bot(bot_id)
+ with contextlib.suppress(Exception):
+ if os.path.isdir(root):
+ shutil.rmtree(root, ignore_errors=True)
+
def run_self_test(self) -> dict:
stack_ok = True
stack_reason = ""
@@ -840,6 +938,8 @@ class ReticulumMeshChat:
rw_ok = False
rw_reason = f"Read/write test failed: {str(e)}"
+ bots_ok, bots_reason = self._check_bot_lifecycle()
+
return {
"stack_up": {
"status": "ok" if stack_ok else "failed",
@@ -857,6 +957,10 @@ class ReticulumMeshChat:
"status": "ok" if rw_ok else "failed",
"reason": rw_reason,
},
+ "bots_lifecycle": {
+ "status": "ok" if bots_ok else "failed",
+ "reason": bots_reason,
+ },
}
@property
@@ -20312,6 +20416,7 @@ def main():
"config_good": "Configuration Integrity",
"db_good": "Database Connection ",
"read_write_good": "Storage Read/Write ",
+ "bots_lifecycle": "Bot Create/Start/Stop ",
}
for key, name in labels.items():

diff --git a/meshchatx/src/backend/bot_handler.py b/meshchatx/src/backend/bot_handler.py
index 43b1a715..c54f11e3 100644
--- a/meshchatx/src/backend/bot_handler.py
+++ b/meshchatx/src/backend/bot_handler.py
@@ -13,9 +13,15 @@ import uuid
import RNS
+from meshchatx.src.backend import bot_process as _bot_process # noqa: F401
+
logger = logging.getLogger("meshchatx.bots")
_LXMF_HASH_RE = re.compile(r"^[0-9a-f]{32}$")
+_BOT_PROCESS_MODULE = "meshchatx.src.backend.bot_process"
+# cx_Freeze / AppImage / macOS bundles set sys.executable to MeshChatX itself.
+# meshchat.main() dispatches this flag to runpy.run_module before argparse.
+_MESHCHATX_RUN_MODULE_FLAG = "--meshchatx-run-module"
class BotHandler:
@@ -38,6 +44,25 @@ class BotHandler:
"bot_process.py",
)
+ @staticmethod
+ def _is_frozen_executable():
+ return bool(getattr(sys, "frozen", False))
+
+ def _resolve_bot_launcher(self):
+ """Return argv prefix for launching bot_process.
+
+ Frozen desktop builds set ``sys.executable`` to MeshChatX itself. Passing
+ a ``.py`` path as argv[1] starts another full app instance and hits the
+ storage lock. Those builds re-enter via ``--meshchatx-run-module``.
+ """
+ if self._is_frozen_executable():
+ return [
+ sys.executable,
+ _MESHCHATX_RUN_MODULE_FLAG,
+ _BOT_PROCESS_MODULE,
+ ]
+ return [sys.executable, self.runner_path]
+
def _load_state(self):
try:
with open(self.state_file, encoding="utf-8") as f:
@@ -308,8 +333,7 @@ class BotHandler:
os.unlink(err_file)
cmd = [
- sys.executable,
- self.runner_path,
+ *self._resolve_bot_launcher(),
"--template",
template_id,
"--name",
@@ -354,6 +378,7 @@ class BotHandler:
"stop_event": None,
"template": template_id,
"pid": proc.pid,
+ "proc": proc,
}
logger.info(f"Started bot {bot_id} (template: {template_id}) pid={proc.pid}")
return bot_id
@@ -379,13 +404,19 @@ class BotHandler:
timeout=5,
)
else:
- os.kill(pid, 15)
+ try:
+ os.killpg(pid, 15)
+ except OSError:
+ os.kill(pid, 15)
# brief wait
time.sleep(0.5)
# optional force kill if still alive
try:
- os.kill(pid, 0)
- os.kill(pid, 9)
+ if self._is_pid_alive(pid):
+ try:
+ os.killpg(pid, 9)
+ except OSError:
+ os.kill(pid, 9)
except OSError:
pass
except Exception as exc:
@@ -395,6 +426,7 @@ class BotHandler:
pid,
exc,
)
+ self._reap_process(bot_id, pid)
entry["pid"] = None
entry["enabled"] = False
@@ -566,9 +598,38 @@ class BotHandler:
return False
try:
os.kill(pid, 0)
- return True
except OSError:
return False
+ # Reaped children are gone; unreaped zombies still answer kill(0).
+ if sys.platform.startswith("linux"):
+ try:
+ with open(f"/proc/{pid}/status", encoding="utf-8") as f:
+ for line in f:
+ if line.startswith("State:"):
+ # Z = zombie, X/x = dead
+ state = line.split(":", 1)[1].strip()
+ if state[:1] in ("Z", "X", "x"):
+ return False
+ break
+ except OSError:
+ return False
+ return True
+
+ def _reap_process(self, bot_id, pid):
+ tracked = self.running_bots.get(bot_id) or {}
+ proc = tracked.get("proc")
+ if proc is not None:
+ with contextlib.suppress(Exception):
+ proc.poll()
+ with contextlib.suppress(Exception):
+ proc.wait(timeout=2)
+ return
+ if not pid:
+ return
+ # Best-effort reap when we still own the child (same parent).
+ if hasattr(os, "waitpid"):
+ with contextlib.suppress(ChildProcessError, OSError):
+ os.waitpid(pid, os.WNOHANG)
def stop_all(self):
seen = set()

diff --git a/meshchatx/src/backend/rnsh_manager.py b/meshchatx/src/backend/rnsh_manager.py
index cb55b552..924fd77b 100644
--- a/meshchatx/src/backend/rnsh_manager.py
+++ b/meshchatx/src/backend/rnsh_manager.py
@@ -367,6 +367,9 @@ class RNSHSession:
env.setdefault("TERM", "xterm-256color")
env["COLUMNS"] = str(self._cols)
env["LINES"] = str(self._rows)
+ # Pipe mode (Windows and pytest) has no TTY, so CPython fully buffers
+ # stdout and listen-address lines never reach the reader promptly.
+ env["PYTHONUNBUFFERED"] = "1"
return env
@staticmethod

diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue
index 71a6d8f2..7079b4d1 100644
--- a/meshchatx/src/frontend/components/settings/SettingsPage.vue
+++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue
@@ -3069,6 +3069,12 @@ export default {
passed: this.selfTestResults.read_write_good.status === "ok",
reason: this.selfTestResults.read_write_good.reason,
},
+ {
+ key: "bots_lifecycle",
+ label: this.$t("selftest.bots_lifecycle"),
+ passed: this.selfTestResults.bots_lifecycle?.status === "ok",
+ reason: this.selfTestResults.bots_lifecycle?.reason || "",
+ },
];
},
allSelfTestChecksPassed() {
@@ -3150,6 +3156,7 @@ export default {
config_good: { status: "failed", reason: e.message || String(e) },
db_good: { status: "failed", reason: e.message || String(e) },
read_write_good: { status: "failed", reason: e.message || String(e) },
+ bots_lifecycle: { status: "failed", reason: e.message || String(e) },
};
} finally {
this.selfTestRunning = false;

diff --git a/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js b/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js
index b94ffcc3..2b9768e9 100644
--- a/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js
+++ b/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js
@@ -98,7 +98,16 @@ export const CORE_SETTINGS_SECTION_KEYWORDS = {
"Export Folders",
"Import Folders",
],
- selftest: ["Self-Test", "System Self-Test", "diagnostic", "checks", "selftest.title", "selftest.description"],
+ selftest: [
+ "Self-Test",
+ "System Self-Test",
+ "diagnostic",
+ "checks",
+ "selftest.title",
+ "selftest.description",
+ "bot",
+ "bots",
+ ],
desktop: [
"Desktop",
"App Behaviour",

diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index debd5a56..d668a3b5 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -704,6 +704,7 @@
"config_good": "Konfigurationsintegrität",
"db_good": "Datenbankverbindung",
"read_write": "Speicher Lese/Schreib",
+ "bots_lifecycle": "Bot Erstellen / Starten / Stoppen / Löschen",
"passed": "Bestanden",
"failed": "Fehlgeschlagen",
"status_label": "Status",

diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index 397af850..d4745738 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -697,13 +697,14 @@
},
"selftest": {
"title": "System Self-Test",
- "description": "Run diagnostic checks to ensure the core network stack, database, configurations, and storage permissions are functional.",
+ "description": "Run diagnostic checks to ensure the core network stack, database, configurations, storage permissions, and bot lifecycle are functional.",
"run_test_btn": "Run Diagnostics",
"running": "Running Diagnostics...",
"stack_up": "Network Stack",
"config_good": "Configuration Integrity",
"db_good": "Database Connection",
"read_write": "Storage Read/Write",
+ "bots_lifecycle": "Bot Create / Start / Stop / Delete",
"passed": "Passed",
"failed": "Failed",
"status_label": "Status",

diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json
index 38d2b14f..0cdf9250 100644
--- a/meshchatx/src/frontend/locales/es.json
+++ b/meshchatx/src/frontend/locales/es.json
@@ -704,6 +704,7 @@
"config_good": "Integridad de configuración",
"db_good": "Conexión a la base de datos",
"read_write": "Lectura/escritura de almacenamiento",
+ "bots_lifecycle": "Bot Crear / Iniciar / Detener / Eliminar",
"passed": "Aprobado",
"failed": "Fallido",
"status_label": "Estado",

diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json
index 4c84c06c..ced61df2 100644
--- a/meshchatx/src/frontend/locales/fi.json
+++ b/meshchatx/src/frontend/locales/fi.json
@@ -704,6 +704,7 @@
"config_good": "Asetusten eheys",
"db_good": "Tietokantayhteys",
"read_write": "Tallennustilan luku/kirjoitus",
+ "bots_lifecycle": "Botin luonti / käynnistys / pysäytys / poisto",
"passed": "Hyväksytty",
"failed": "Epäonnistunut",
"status_label": "Tila",

diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json
index e1ae0ba9..c561309d 100644
--- a/meshchatx/src/frontend/locales/fr.json
+++ b/meshchatx/src/frontend/locales/fr.json
@@ -704,6 +704,7 @@
"config_good": "Intégrité de la configuration",
"db_good": "Connexion à la base de données",
"read_write": "Lecture/Écriture stockage",
+ "bots_lifecycle": "Bot Créer / Démarrer / Arrêter / Supprimer",
"passed": "Réussi",
"failed": "Échoué",
"status_label": "Statut",

diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index acd7e228..ef5adb58 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -704,6 +704,7 @@
"config_good": "Integrità Configurazione",
"db_good": "Connessione Database",
"read_write": "Lettura/Scrittura Archivio",
+ "bots_lifecycle": "Bot Crea / Avvia / Ferma / Elimina",
"passed": "Superato",
"failed": "Fallito",
"status_label": "Stato",

diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json
index 646a4b72..64bb8e87 100644
--- a/meshchatx/src/frontend/locales/nl.json
+++ b/meshchatx/src/frontend/locales/nl.json
@@ -704,6 +704,7 @@
"config_good": "Configuratie-integriteit",
"db_good": "Databaseverbinding",
"read_write": "Opslag Lezen/Schrijven",
+ "bots_lifecycle": "Bot Aanmaken / Starten / Stoppen / Verwijderen",
"passed": "Geslaagd",
"failed": "Mislukt",
"status_label": "Status",

diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index 6fecd8e8..7f82cc25 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -704,6 +704,7 @@
"config_good": "Целостность конфигурации",
"db_good": "Подключение к базе данных",
"read_write": "Чтение/запись в хранилище",
+ "bots_lifecycle": "Бот: создать / запустить / остановить / удалить",
"passed": "Успешно",
"failed": "Ошибка",
"status_label": "Статус",

diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json
index bd98d0c6..0c0fd2c8 100644
--- a/meshchatx/src/frontend/locales/zh.json
+++ b/meshchatx/src/frontend/locales/zh.json
@@ -704,6 +704,7 @@
"config_good": "配置完整性",
"db_good": "数据库连接",
"read_write": "存储读/写",
+ "bots_lifecycle": "机器人 创建 / 启动 / 停止 / 删除",
"passed": "已通过",
"failed": "已失败",
"status_label": "状态",

diff --git a/tests/backend/api_json_contract_schemas.py b/tests/backend/api_json_contract_schemas.py
index 5508f93e..b9d54d4d 100644
--- a/tests/backend/api_json_contract_schemas.py
+++ b/tests/backend/api_json_contract_schemas.py
@@ -199,12 +199,19 @@ SELF_TEST_STATUS_ITEM_SCHEMA: dict = {
SELF_TEST_SCHEMA: dict = {
"type": "object",
- "required": ["stack_up", "config_good", "db_good", "read_write_good"],
+ "required": [
+ "stack_up",
+ "config_good",
+ "db_good",
+ "read_write_good",
+ "bots_lifecycle",
+ ],
"properties": {
"stack_up": SELF_TEST_STATUS_ITEM_SCHEMA,
"config_good": SELF_TEST_STATUS_ITEM_SCHEMA,
"db_good": SELF_TEST_STATUS_ITEM_SCHEMA,
"read_write_good": SELF_TEST_STATUS_ITEM_SCHEMA,
+ "bots_lifecycle": SELF_TEST_STATUS_ITEM_SCHEMA,
},
"additionalProperties": False,
}

diff --git a/tests/backend/support/run_module_probe.py b/tests/backend/support/run_module_probe.py
new file mode 100644
index 00000000..ee62b504
--- /dev/null
+++ b/tests/backend/support/run_module_probe.py
@@ -0,0 +1,22 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Probe module for ``--meshchatx-run-module`` live/subprocess tests."""
+
+from __future__ import annotations
+
+import os
+import sys
+
+
+def main() -> None:
+ marker = os.environ.get("MESHCHATX_RUN_MODULE_PROBE_PATH")
+ if marker:
+ with open(marker, "w", encoding="utf-8") as handle:
+ handle.write("ok\n")
+ handle.write(" ".join(sys.argv[1:]))
+ handle.write("\n")
+ print("meshchatx-run-module-probe", *sys.argv[1:], flush=True)
+
+
+if __name__ == "__main__":
+ main()

diff --git a/tests/backend/test_bot_handler_extended.py b/tests/backend/test_bot_handler_extended.py
index 72d1ce9c..91b2278b 100644
--- a/tests/backend/test_bot_handler_extended.py
+++ b/tests/backend/test_bot_handler_extended.py
@@ -1,6 +1,7 @@
# SPDX-License-Identifier: 0BSD
import os
+import sys
from unittest.mock import MagicMock, patch
import pytest
@@ -141,6 +142,41 @@ def test_start_stop_bot(mock_popen, temp_identity_dir):
assert bot_id not in handler.running_bots
+@patch("subprocess.Popen")
+def test_start_bot_unfrozen_uses_bot_process_script(mock_popen, temp_identity_dir):
+ mock_process = MagicMock()
+ mock_process.pid = 4242
+ mock_popen.return_value = mock_process
+
+ handler = BotHandler(temp_identity_dir)
+ with patch.object(BotHandler, "_is_frozen_executable", return_value=False):
+ handler.start_bot("echo", "Script Bot")
+
+ cmd = mock_popen.call_args.args[0]
+ assert cmd[0] == sys.executable
+ assert cmd[1] == handler.runner_path
+ assert "--meshchatx-run-module" not in cmd
+ assert cmd[cmd.index("--template") + 1] == "echo"
+
+
+@patch("subprocess.Popen")
+def test_start_bot_frozen_uses_meshchatx_run_module(mock_popen, temp_identity_dir):
+ mock_process = MagicMock()
+ mock_process.pid = 4243
+ mock_popen.return_value = mock_process
+
+ handler = BotHandler(temp_identity_dir)
+ with patch.object(BotHandler, "_is_frozen_executable", return_value=True):
+ handler.start_bot("echo", "Frozen Bot")
+
+ cmd = mock_popen.call_args.args[0]
+ assert cmd[0] == sys.executable
+ assert cmd[1] == "--meshchatx-run-module"
+ assert cmd[2] == "meshchatx.src.backend.bot_process"
+ assert handler.runner_path not in cmd
+ assert cmd[cmd.index("--template") + 1] == "echo"
+
+
def test_update_bot_name_writes_sidecar(temp_identity_dir):
handler = BotHandler(temp_identity_dir)
sid = "b1"

diff --git a/tests/backend/test_bot_lifecycle_live.py b/tests/backend/test_bot_lifecycle_live.py
new file mode 100644
index 00000000..fd4ac373
--- /dev/null
+++ b/tests/backend/test_bot_lifecycle_live.py
@@ -0,0 +1,99 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Live create / start / stop / delete coverage for LXMFy bot subprocesses."""
+
+from __future__ import annotations
+
+import os
+import time
+
+import pytest
+
+from meshchatx.src.backend.bot_handler import BotHandler
+
+
+@pytest.fixture
+def bot_lifecycle_dirs(tmp_path, monkeypatch):
+ identity_dir = tmp_path / "identity"
+ rns_dir = tmp_path / "reticulum"
+ identity_dir.mkdir()
+ rns_dir.mkdir()
+ monkeypatch.setenv("MESHCHAT_BOT_RETICULUM_CONFIG_DIR", str(rns_dir))
+ return identity_dir, rns_dir
+
+
+def _wait_pid_dead(pid: int, timeout: float = 5.0) -> bool:
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ if not BotHandler._is_pid_alive(pid):
+ return True
+ time.sleep(0.05)
+ return not BotHandler._is_pid_alive(pid)
+
+
+def test_bot_create_start_stop_delete_live(bot_lifecycle_dirs):
+ identity_dir, _rns_dir = bot_lifecycle_dirs
+ handler = BotHandler(str(identity_dir))
+
+ bot_id = handler.start_bot("echo", "Lifecycle Echo")
+ entry = next(e for e in handler.bots_state if e["id"] == bot_id)
+ pid = entry["pid"]
+ storage_dir = entry["storage_dir"]
+
+ assert pid
+ assert BotHandler._is_pid_alive(pid)
+ assert os.path.isdir(storage_dir)
+
+ # Give the child a moment to write its start banner / RNS config.
+ deadline = time.monotonic() + 8.0
+ while time.monotonic() < deadline:
+ if not BotHandler._is_pid_alive(pid):
+ err = BotHandler._read_bot_last_error(storage_dir)
+ log = handler.read_subprocess_log(bot_id)
+ pytest.fail(
+ f"bot died early: err={err!r} log={log.get('log')!r}",
+ )
+ log = handler.read_subprocess_log(bot_id)
+ if (log.get("total_bytes") or 0) > 0:
+ break
+ time.sleep(0.1)
+
+ assert handler.stop_bot(bot_id) is True
+ assert _wait_pid_dead(pid)
+ assert bot_id not in handler.running_bots
+
+ status = handler.get_status()
+ stopped = next(b for b in status["bots"] if b["id"] == bot_id)
+ assert stopped["running"] is False
+
+ assert handler.delete_bot(bot_id) is True
+ assert not any(e.get("id") == bot_id for e in handler.bots_state)
+ assert not os.path.exists(storage_dir)
+
+
+def test_bot_frozen_launcher_prefix_avoids_script_path(bot_lifecycle_dirs, monkeypatch):
+ identity_dir, _rns_dir = bot_lifecycle_dirs
+ handler = BotHandler(str(identity_dir))
+ monkeypatch.setattr(BotHandler, "_is_frozen_executable", staticmethod(lambda: True))
+
+ cmd = handler._resolve_bot_launcher()
+ assert cmd[0]
+ assert cmd[1] == "--meshchatx-run-module"
+ assert cmd[2] == "meshchatx.src.backend.bot_process"
+ assert handler.runner_path not in cmd
+
+
+def test_check_bot_lifecycle_self_test_helper(tmp_path):
+ """Exercise ReticulumMeshChat._check_bot_lifecycle without full app boot."""
+ from meshchatx.meshchat import ReticulumMeshChat
+
+ storage = tmp_path / "storage"
+ storage.mkdir()
+ app = ReticulumMeshChat.__new__(ReticulumMeshChat)
+ app.current_context = None
+ app.storage_dir = str(storage)
+
+ ok, reason = app._check_bot_lifecycle()
+ assert ok is True, reason
+ assert reason == ""
+ assert not (storage / ".self_test_bots").exists()

diff --git a/tests/backend/test_meshchatx_run_module.py b/tests/backend/test_meshchatx_run_module.py
new file mode 100644
index 00000000..68de6463
--- /dev/null
+++ b/tests/backend/test_meshchatx_run_module.py
@@ -0,0 +1,144 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Tests for MeshChatX ``--meshchatx-run-module`` frozen re-entry."""
+
+from __future__ import annotations
+
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+from meshchatx.meshchat import _maybe_run_embedded_module
+from tests.backend.support.test_temp_dir import subprocess_test_env
+
+
+def test_maybe_run_embedded_module_noop_without_flag(monkeypatch):
+ monkeypatch.setattr(sys, "argv", ["meshchatx", "--headless"])
+ assert _maybe_run_embedded_module() is False
+
+
+def test_maybe_run_embedded_module_requires_module_name(monkeypatch):
+ monkeypatch.setattr(sys, "argv", ["meshchatx", "--meshchatx-run-module"])
+ assert _maybe_run_embedded_module() is False
+
+
+def test_maybe_run_embedded_module_rejects_flag_as_module_name(monkeypatch, capsys):
+ monkeypatch.setattr(
+ sys,
+ "argv",
+ ["meshchatx", "--meshchatx-run-module", "--headless"],
+ )
+ with pytest.raises(SystemExit) as exc:
+ _maybe_run_embedded_module()
+ assert exc.value.code == 2
+ err = capsys.readouterr().err
+ assert "--meshchatx-run-module requires a module name" in err
+
+
+def test_maybe_run_embedded_module_runs_probe_and_rewrites_argv(
+ monkeypatch,
+ tmp_path,
+):
+ marker = tmp_path / "probe.out"
+ monkeypatch.setenv("MESHCHATX_RUN_MODULE_PROBE_PATH", str(marker))
+ monkeypatch.setattr(
+ sys,
+ "argv",
+ [
+ "meshchatx",
+ "--meshchatx-run-module",
+ "tests.backend.support.run_module_probe",
+ "--alpha",
+ "1",
+ ],
+ )
+ assert _maybe_run_embedded_module() is True
+ assert marker.read_text(encoding="utf-8") == "ok\n--alpha 1\n"
+ assert sys.argv[0] == "meshchatx"
+ assert sys.argv[1:] == ["--alpha", "1"]
+
+
+def test_meshchat_main_dispatches_run_module_before_app_startup(
+ monkeypatch,
+ tmp_path,
+):
+ from meshchatx import meshchat as meshchat_mod
+
+ marker = tmp_path / "probe.out"
+ monkeypatch.setenv("MESHCHATX_RUN_MODULE_PROBE_PATH", str(marker))
+ monkeypatch.setattr(
+ sys,
+ "argv",
+ [
+ "meshchatx",
+ "--meshchatx-run-module",
+ "tests.backend.support.run_module_probe",
+ "from-main",
+ ],
+ )
+
+ called = {"crash": False}
+
+ def _boom():
+ called["crash"] = True
+ raise AssertionError("CrashRecovery must not run for module re-entry")
+
+ monkeypatch.setattr(meshchat_mod, "CrashRecovery", _boom)
+ meshchat_mod.main()
+ assert called["crash"] is False
+ assert marker.read_text(encoding="utf-8") == "ok\nfrom-main\n"
+
+
+def test_meshchatx_run_module_subprocess_live(tmp_path):
+ marker = tmp_path / "probe.out"
+ env = subprocess_test_env(
+ {
+ "MESHCHATX_RUN_MODULE_PROBE_PATH": str(marker),
+ "PYTHONPATH": str(Path(__file__).resolve().parents[2]),
+ },
+ )
+ result = subprocess.run(
+ [
+ sys.executable,
+ "-m",
+ "meshchatx.meshchat",
+ "--meshchatx-run-module",
+ "tests.backend.support.run_module_probe",
+ "live-arg",
+ ],
+ capture_output=True,
+ text=True,
+ timeout=60,
+ check=False,
+ env=env,
+ )
+ assert result.returncode == 0, result.stderr + result.stdout
+ assert "meshchatx-run-module-probe live-arg" in result.stdout
+ assert marker.read_text(encoding="utf-8") == "ok\nlive-arg\n"
+
+
+def test_meshchatx_run_module_subprocess_can_invoke_rnsh_help():
+ result = subprocess.run(
+ [
+ sys.executable,
+ "-m",
+ "meshchatx.meshchat",
+ "--meshchatx-run-module",
+ "RNS.Utilities.rnsh.rnsh",
+ "--help",
+ ],
+ capture_output=True,
+ text=True,
+ timeout=60,
+ check=False,
+ env=subprocess_test_env(),
+ )
+ combined = result.stdout + result.stderr
+ # rnsh prints help to stdout and exits 1 (argparse with no destination).
+ assert result.returncode in (0, 1), combined
+ assert "usage:" in combined.lower() or "rnsh" in combined.lower()
+ assert "-l" in combined
+ assert "unrecognized arguments: -m" not in combined
+ assert "unrecognized arguments: --meshchatx-run-module" not in combined

diff --git a/tests/backend/test_rnsh_api.py b/tests/backend/test_rnsh_api.py
index 845b71db..1624039b 100644
--- a/tests/backend/test_rnsh_api.py
+++ b/tests/backend/test_rnsh_api.py
@@ -1,6 +1,6 @@
# SPDX-License-Identifier: 0BSD
-"""Tests for RNSh manager HTTP API endpoints."""
+"""Tests for RNSh manager HTTP API endpoints and launcher argv."""
import json
from unittest.mock import MagicMock
@@ -232,6 +232,69 @@ def test_rnsh_frozen_uses_meshchatx_run_module(monkeypatch):
assert command[-1] == "deadbeef"
+def test_rnsh_frozen_listen_command_keeps_mirror_flag_separate(monkeypatch):
+ from meshchatx.src.backend import rnsh_manager as rnsh_mod
+
+ monkeypatch.setattr(rnsh_mod.RNSHSession, "_rnsh_module_available", lambda: True)
+ monkeypatch.setattr(rnsh_mod.RNSHSession, "_is_frozen_executable", lambda: True)
+ monkeypatch.setattr(rnsh_mod.sys, "executable", "/opt/ReticulumMeshChatX")
+
+ manager = MagicMock()
+ manager.reticulum_config_dir = "/tmp/rns-config"
+ session = rnsh_mod.RNSHSession(
+ manager,
+ "s1",
+ {
+ "mode": "listen",
+ "mirror": True,
+ "no_auth": True,
+ "quiet": 1,
+ "config_path": "/tmp/session-config",
+ },
+ )
+ command = session._build_command()
+ assert command[:3] == [
+ "/opt/ReticulumMeshChatX",
+ rnsh_mod._MESHCHATX_RUN_MODULE_FLAG,
+ rnsh_mod._RNSH_MODULE,
+ ]
+ assert command.count("-m") == 1
+ assert command.index("-m") > command.index(rnsh_mod._RNSH_MODULE)
+ assert "-c" in command
+ assert command[command.index("-c") + 1] == "/tmp/session-config"
+ assert "-l" in command
+ assert "-n" in command
+ assert "-q" in command
+
+
+def test_rnsh_connect_command_includes_destination_and_remote(monkeypatch):
+ from meshchatx.src.backend import rnsh_manager as rnsh_mod
+
+ monkeypatch.setattr(rnsh_mod.RNSHSession, "_rnsh_module_available", lambda: True)
+ monkeypatch.setattr(rnsh_mod.RNSHSession, "_is_frozen_executable", lambda: False)
+
+ manager = MagicMock()
+ manager.reticulum_config_dir = "/shared/rns"
+ session = rnsh_mod.RNSHSession(
+ manager,
+ "s1",
+ {
+ "mode": "connect",
+ "destination": "aabbccddeeff0011",
+ "remote_command": "uname -a",
+ "no_id": True,
+ },
+ )
+ command = session._build_command()
+ assert command[:3] == [rnsh_mod.sys.executable, "-m", rnsh_mod._RNSH_MODULE]
+ assert "-c" in command
+ assert command[command.index("-c") + 1] == "/shared/rns"
+ assert "-N" in command
+ assert "aabbccddeeff0011" in command
+ assert "--" in command
+ assert command[command.index("--") + 1 :] == ["uname", "-a"]
+
+
def test_rnsh_falls_back_to_path_binary_when_module_missing(monkeypatch):
from meshchatx.src.backend import rnsh_manager as rnsh_mod
@@ -271,6 +334,22 @@ def test_rnsh_non_executable_path_wrapper_raises_permission_error(monkeypatch):
session._build_command()
+def test_rnsh_missing_module_and_binary_raises_file_not_found(monkeypatch):
+ from meshchatx.src.backend import rnsh_manager as rnsh_mod
+
+ monkeypatch.setattr(rnsh_mod.RNSHSession, "_rnsh_module_available", lambda: False)
+ monkeypatch.setattr(rnsh_mod.shutil, "which", lambda _name: None)
+
+ manager = MagicMock()
+ session = rnsh_mod.RNSHSession(
+ manager,
+ "s1",
+ {"mode": "connect", "destination": "deadbeef"},
+ )
+ with pytest.raises(FileNotFoundError, match="rnsh is not available"):
+ session._build_command()
+
+
@pytest.mark.asyncio
async def test_rnsh_session_not_found_returns_404(mock_app):
mock_app.rnsh_manager = _DummyManager()

diff --git a/tests/backend/test_rnsh_live.py b/tests/backend/test_rnsh_live.py
new file mode 100644
index 00000000..6b0025d4
--- /dev/null
+++ b/tests/backend/test_rnsh_live.py
@@ -0,0 +1,169 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Live RNSh session smoke tests.
+
+These start a real rnsh listener through RNSHSession and wait for the listen
+address to appear in process output. They are opt-in because they spawn a
+Reticulum process and take a few seconds.
+
+Enable with: MESHCHAT_LIVE_RNSH=1
+"""
+
+from __future__ import annotations
+
+import importlib.util
+import os
+import shutil
+import tempfile
+import textwrap
+import time
+
+import pytest
+
+from meshchatx.src.backend.rnsh_manager import (
+ RNSHSession,
+ _MESHCHATX_RUN_MODULE_FLAG,
+ _RNSH_MODULE,
+)
+
+_RUN = os.environ.get("MESHCHAT_LIVE_RNSH") == "1"
+_RNSH_AVAILABLE = importlib.util.find_spec(_RNSH_MODULE) is not None
+
+
+class _LiveManager:
+ def __init__(self, reticulum_config_dir: str):
+ self.reticulum_config_dir = reticulum_config_dir
+ self.changes = 0
+
+ def _on_session_change(self, _session):
+ self.changes += 1
+
+ def save(self):
+ return None
+
+
+def _force_pipe_mode(monkeypatch, session_cls=RNSHSession):
+ # PTY children often get SIGHUP under pytest capture; use pipes for live smoke.
+ monkeypatch.setattr(session_cls, "_supports_pty", staticmethod(lambda: False))
+
+
+def _wait_for_listen_address(session: RNSHSession, timeout: float = 30.0) -> str:
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ if session.listen_address:
+ return session.listen_address
+ if session.status == RNSHSession.STATUS_FAILED:
+ raise AssertionError(
+ f"rnsh failed before listen address: {session.last_error!r} "
+ f"output={session.to_dict(include_output_tail=True).get('output_text')!r}",
+ )
+ time.sleep(0.2)
+ payload = session.to_dict(include_output_tail=True)
+ raise AssertionError(
+ f"timed out waiting for rnsh listen address; status={session.status} "
+ f"output={payload.get('output_text')!r}",
+ )
+
+
+@pytest.mark.integration
+@pytest.mark.skipif(not _RUN, reason="Set MESHCHAT_LIVE_RNSH=1 to run live RNSh tests")
+@pytest.mark.skipif(
+ not _RNSH_AVAILABLE, reason="RNS.Utilities.rnsh.rnsh is not installed"
+)
+def test_rnsh_live_listen_session_reports_address(monkeypatch):
+ _force_pipe_mode(monkeypatch)
+ tmpdir = tempfile.mkdtemp(prefix="meshchat_rnsh_live_")
+ manager = _LiveManager(tmpdir)
+ session = RNSHSession(
+ manager,
+ "live-listen",
+ {
+ "mode": "listen",
+ "no_auth": True,
+ "quiet": 1,
+ "config_path": tmpdir,
+ },
+ )
+ try:
+ started = session.start()
+ assert started["status"] == RNSHSession.STATUS_RUNNING
+ assert started["pid"]
+ assert "-m" in started["last_command"] or "rnsh" in started["last_command"]
+ assert "--meshchatx-run-module" not in started["last_command"]
+ assert session._build_env().get("PYTHONUNBUFFERED") == "1"
+
+ address = _wait_for_listen_address(session)
+ assert len(address) >= 16
+ assert all(ch in "0123456789abcdef" for ch in address)
+ assert session.status == RNSHSession.STATUS_RUNNING
+ finally:
+ session.stop()
+ shutil.rmtree(tmpdir, ignore_errors=True)
+
+ assert session.status == RNSHSession.STATUS_STOPPED
+ assert session.listen_address
+
+
+@pytest.mark.integration
+@pytest.mark.skipif(not _RUN, reason="Set MESHCHAT_LIVE_RNSH=1 to run live RNSh tests")
+@pytest.mark.skipif(
+ not _RNSH_AVAILABLE, reason="RNS.Utilities.rnsh.rnsh is not installed"
+)
+def test_rnsh_live_meshchatx_run_module_launcher_starts_listener(monkeypatch, tmp_path):
+ """Frozen-style launcher: MeshChatX re-entry flag, executed via a shim script."""
+ from meshchatx.src.backend import rnsh_manager as rnsh_mod
+
+ shim = tmp_path / "meshchatx_shim.py"
+ shim.write_text(
+ textwrap.dedent(
+ """ import runpy
+ import sys
+
+ marker = "--meshchatx-run-module"
+ if len(sys.argv) < 3 or sys.argv[1] != marker:
+ raise SystemExit("shim expected --meshchatx-run-module")
+ module_name = sys.argv[2]
+ sys.argv = [sys.argv[0], *sys.argv[3:]]
+ runpy.run_module(module_name, run_name="__main__", alter_sys=True)
+ """
+ ),
+ encoding="utf-8",
+ )
+
+ real_python = rnsh_mod.sys.executable
+
+ def _frozen_style_launcher():
+ return [real_python, str(shim), _MESHCHATX_RUN_MODULE_FLAG, _RNSH_MODULE]
+
+ monkeypatch.setattr(rnsh_mod.RNSHSession, "_is_frozen_executable", lambda: True)
+ monkeypatch.setattr(
+ rnsh_mod.RNSHSession,
+ "_resolve_rnsh_launcher",
+ staticmethod(_frozen_style_launcher),
+ )
+ _force_pipe_mode(monkeypatch, rnsh_mod.RNSHSession)
+
+ tmpdir = tempfile.mkdtemp(prefix="meshchat_rnsh_runmod_live_")
+ manager = _LiveManager(tmpdir)
+ session = rnsh_mod.RNSHSession(
+ manager,
+ "live-run-module",
+ {
+ "mode": "listen",
+ "no_auth": True,
+ "quiet": 1,
+ "config_path": tmpdir,
+ },
+ )
+ try:
+ command = session._build_command()
+ assert command[1:4] == [str(shim), _MESHCHATX_RUN_MODULE_FLAG, _RNSH_MODULE]
+ assert "-m" not in command
+ started = session.start()
+ assert started["status"] == RNSHSession.STATUS_RUNNING
+ assert _MESHCHATX_RUN_MODULE_FLAG in started["last_command"]
+ address = _wait_for_listen_address(session)
+ assert len(address) >= 16
+ finally:
+ session.stop()
+ shutil.rmtree(tmpdir, ignore_errors=True)

diff --git a/tests/backend/test_self_check_cli.py b/tests/backend/test_self_check_cli.py
index f83865e5..e06cbc24 100644
--- a/tests/backend/test_self_check_cli.py
+++ b/tests/backend/test_self_check_cli.py
@@ -82,6 +82,7 @@ def test_self_check_cli_success(mock_rns, temp_dir):
"config_good": {"status": "ok", "reason": ""},
"db_good": {"status": "ok", "reason": ""},
"read_write_good": {"status": "ok", "reason": ""},
+ "bots_lifecycle": {"status": "ok", "reason": ""},
}
with (
@@ -111,6 +112,7 @@ def test_self_check_cli_failure(mock_rns, temp_dir):
"config_good": {"status": "ok", "reason": ""},
"db_good": {"status": "failed", "reason": "Database check failed"},
"read_write_good": {"status": "ok", "reason": ""},
+ "bots_lifecycle": {"status": "ok", "reason": ""},
}
with (
@@ -137,6 +139,7 @@ def test_self_check_env_var_success(mock_rns, temp_dir):
"config_good": {"status": "ok", "reason": ""},
"db_good": {"status": "ok", "reason": ""},
"read_write_good": {"status": "ok", "reason": ""},
+ "bots_lifecycle": {"status": "ok", "reason": ""},
}
env = {

diff --git a/tests/e2e/smoke.spec.js b/tests/e2e/smoke.spec.js
index e1584c2e..27c1e011 100644
--- a/tests/e2e/smoke.spec.js
+++ b/tests/e2e/smoke.spec.js
@@ -19,6 +19,7 @@ test.describe("MeshChatX E2E (Vite + Python backend)", () => {
expect(body.config_good.status).toBe("ok");
expect(body.db_good.status).toBe("ok");
expect(body.read_write_good.status).toBe("ok");
+ expect(body.bots_lifecycle.status).toBe("ok");
});
test("backend /api/v1/app/info returns version JSON (direct backend)", async ({ request }) => {


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────